/**
 * Copyright (C) 2008 Mathieu Carbou <mathieu.carbou@gmail.com>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.code.xmltool;

import com.google.code.xmltool.util.ValidationResult;
import com.google.code.xmltool.util.XMLDocValidator;
import org.w3c.dom.*;
import org.xml.sax.InputSource;

import javax.xml.XMLConstants;
import javax.xml.namespace.NamespaceContext;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.File;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringWriter;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;

/**
 * @author Mathieu Carbou (mathieu.carbou@gmail.com)
 */
public final class XMLDoc implements XMLDocument {

    private final XMLDocDefinition definition;
    private Element current;

    XMLDoc(XMLDocDefinition definition) {
        this.definition = definition;
        this.current = definition.getRoot();
    }

    public NamespaceContext getContext() {
        return definition;
    }

    public String getPefix(String namespaceURI) {
        String prefix = getContext().getPrefix(namespaceURI);
        return prefix == null ? XMLConstants.DEFAULT_NS_PREFIX : prefix;
    }

    public XMLDocument addNamespace(String prefix, String namespaceURI) {
        definition.addNamespace(prefix, namespaceURI);
        return this;
    }

    public XMLDocument addTag(String name) {
        Element el = definition.createElement(name);
        current.appendChild(el);
        current = el;
        return this;
    }

    public XMLDocument addAttribute(String name, String value) {
        current.setAttributeNodeNS(definition.createAttribute(name, value));
        return this;
    }

    public XMLDocument addText(String text) {
        current.appendChild(definition.createText(text));
        return gotoParent();
    }

    public XMLDocument addCDATA(String data) {
        current.appendChild(definition.createCDATA(data));
        return gotoParent();
    }

    public XMLDocument delete() {
        if (current == definition.getRoot()) {
            throw new XMLDocumentException("Cannot root node '%s'", getCurrentTagName());
        }
        Node toDelete = current;
        gotoParent();
        current.removeChild(toDelete);
        return this;
    }

    public XMLDocument deleteChilds() {
        Element[] toDelete = childs(current);
        for (Element node : toDelete) {
            current.removeChild(node);
        }
        return this;
    }

    public XMLDocument deleteAttributes() {
        Attr[] attrs = attr(current);
        for (Attr attr : attrs) {
            current.removeAttributeNode(attr);
        }
        return this;
    }

    public XMLDocument deleteAttribute(String name) {
        if (!current.hasAttribute(name)) {
            throw new XMLDocumentException("Cannot delete attribute '%s' from element '%s': attribute does noe exist", name, getCurrentTagName());
        }
        current.removeAttribute(name);
        return this;
    }

    public XMLDocument renameTo(String newNodeName) {
        definition.rename(current, newNodeName);
        return this;
    }

    public XMLDocument gotoParent() {
        Node n = current.getParentNode();
        if (current != definition.getRoot() && isElement(n)) {
            current = (Element) n;
        }
        return this;
    }

    public XMLDocument gotoRoot() {
        current = definition.getRoot();
        return this;
    }

    public XMLDocument gotoChild() {
        Element[] els = childs(current);
        switch (els.length) {
            case 0:
                throw new XMLDocumentException("Current element '%s' has no child", getCurrentTagName());
            case 1:
                current = els[0];
                break;
            default:
                throw new XMLDocumentException("Cannot select child: current element '%s' has '%s' children", getCurrentTagName(), els.length);
        }
        return this;
    }

    public XMLDocument gotoChild(int i) {
        Element[] els = childs(current);
        if (i > 0 && i <= els.length) {
            current = els[i-1];
        } else {
            throw new XMLDocumentException("Cannot acces child '%s' of element '%s' amongst its '%s' childs", i, getCurrentTagName(), els.length);
        }
        return this;
    }

    public XMLDocument gotoChild(String nodeName) {
        Element[] els = childs(current);
        List<Element> found = new ArrayList<Element>(els.length);
        for (Element el : els) {
            if (el.getTagName().equals(nodeName)) {
                found.add(el);
            }
        }
        switch (found.size()) {
            case 0:
                throw new XMLDocumentException("Current element '%s' has no child named '%s'", getCurrentTagName(), nodeName);
            case 1:
                current = found.get(0);
                break;
            default:
                throw new XMLDocumentException("Cannot select child: current element '%s' has '%s' children named '%s'", getCurrentTagName(), found.size(), nodeName);
        }
        return this;
    }

    public XMLDocument gotoTag(String relativeXpath, Object... arguments) {
        Node n = definition.getXpath().findNode(current, relativeXpath, arguments);
        if (!isElement(n)) {
            throw new XMLDocumentException("XPath expression '%s' does not target an element. Targeted node is '%s' (node type is '%s')", String.format(relativeXpath, arguments), n.getNodeName(), n.getNodeType());
        }
        current = (Element) n;
        return this;
    }

    public Element getCurrentTag() {
        return current;
    }

    public String getCurrentTagName() {
        return current.getTagName();
    }

    public String getAttribute(String name) {
        if (!current.hasAttribute(name)) {
            throw new XMLDocumentException("Element '%s' does not have attribute '%s'", getCurrentTagName(), name);
        }
        return current.getAttribute(name);
    }

    public String getAttribute(String name, String relativeXpath, Object... arguments) {
        Element old = current;
        String val = gotoTag(relativeXpath, arguments).getAttribute(name);
        current = old;
        return val;
    }

    @SuppressWarnings({"MismatchedReadAndWriteOfArray"})
    public String[] getAttributeNames() {
        Attr[] attrs = attr(current);
        String[] names = new String[attrs.length];
        for (int i = 0; i < attrs.length; i++) {
            names[i] = (attrs[i]).getName();
        }
        return names;
    }

    public String getText(String relativeXpath, Object... arguments) {
        Element old = current;
        String text = gotoTag(relativeXpath, arguments).getText();
        current = old;
        return text;
    }

    public String getText() {
        StringBuilder sb = new StringBuilder();
        NodeList childs = current.getChildNodes();
        for (int i = 0; i < childs.getLength(); i++) {
            if (childs.item(i).getNodeType() == Document.TEXT_NODE) {
                String val = (childs.item(i)).getNodeValue();
                if (val != null) {
                    sb.append(val);
                }
            }
        }
        return sb.toString();
    }

    public String getCDATA(String relativeXpath, Object... arguments) {
        Element old = current;
        String data = gotoTag(relativeXpath, arguments).getCDATA();
        current = old;
        return data;
    }

    public String getCDATA() {
        StringBuilder sb = new StringBuilder();
        NodeList childs = current.getChildNodes();
        for (int i = 0; i < childs.getLength(); i++) {
            if (childs.item(i).getNodeType() == Document.CDATA_SECTION_NODE) {
                String val = ((CDATASection) childs.item(i)).getData();
                if (val != null) {
                    sb.append(val);
                }
            }
        }
        return sb.toString();
    }

    public Document toDocument() {
        return definition.getDocument();
    }

    public String toString(String encoding) {
        try {
            TransformerFactory tf = TransformerFactory.newInstance();
            Transformer transformer = tf.newTransformer();
            transformer.setOutputProperty(OutputKeys.METHOD, "xml");
            transformer.setOutputProperty(OutputKeys.ENCODING, encoding);
            transformer.setOutputProperty(OutputKeys.STANDALONE, "yes");
            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
            transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
            StringWriter sw = new StringWriter();
            transformer.transform(new DOMSource(toDocument()), new StreamResult(sw));
            return sw.toString();
        }
        catch (TransformerException e) {
            throw new XMLDocumentException("String transformation error", e);
        }
    }

    @Override
    public String toString() {
        return toString("UTF-8");
    }

    public ValidationResult validate(Source... schemas) {
        try {
            return XMLDocValidator.validate(this.toDocument(), schemas);
        }
        catch (Exception e) {
            throw new XMLDocumentException("Validation failed", e);
        }
    }

    public ValidationResult validate(URL... schemaLocations) {
        try {
            return XMLDocValidator.validate(this.toDocument(), schemaLocations);
        }
        catch (Exception e) {
            throw new XMLDocumentException("Validation failed", e);
        }
    }

    private boolean isElement(Node n) {
        return n != null && n.getNodeType() == Document.ELEMENT_NODE;
    }

    private Element[] childs(Element e) {
        NodeList list = e.getChildNodes();
        List<Element> els = new ArrayList<Element>(list.getLength());
        for (int i = 0; i < list.getLength(); i++) {
            if (isElement(list.item(i))) {
                els.add((Element) list.item(i));
            }
        }
        return els.toArray(new Element[els.size()]);
    }

    private Attr[] attr(Element e) {
        NamedNodeMap list = e.getAttributes();
        Attr[] attrs = new Attr[list.getLength()];
        for (int i = 0; i < list.getLength(); i++) {
            attrs[i] = (Attr) list.item(i);
        }
        return attrs;
    }

    /**
     * Create a new empty document
     *
     * @return A document builder helper
     */
    public static XMLDocBuilder newDocument() {
        return XMLDocBuilder.newDocument();
    }

    public static XMLDocument from(Node node) {
        return XMLDocBuilder.from(node);
    }

    public static XMLDocument from(InputSource source) {
        return XMLDocBuilder.from(source);
    }

    public static XMLDocument from(Reader reader) {
        return XMLDocBuilder.from(reader);
    }

    public static XMLDocument from(InputStream is) {
        return XMLDocBuilder.from(is);
    }

    public static XMLDocument from(File file) {
        return XMLDocBuilder.from(file);
    }

    public static XMLDocument from(URL xmlLocation) {
        return XMLDocBuilder.from(xmlLocation);
    }

    public static XMLDocument from(String xmlData) {
        return XMLDocBuilder.from(xmlData);
    }

    public static XMLDocument from(Source source) {
        return XMLDocBuilder.from(source);
    }

}
